计算机系统 - 基础认知
Table of Contents
Table of Contents
一、数据基础单元:位、字节、字
1.1 位(Bit)
- 位是计算机中最小的数据单位,只能表示
0或1(二进制)。 - 物理上对应电路的低电平 / 高电平。
1.2 字节(Byte)
| 单位 | 换算 |
|---|---|
| 1 B | 8 bit |
| 1 KB | 1024 B |
| 1 MB | 1024 KB |
| 1 GB | 1024 MB |
- 字节是内存寻址的基本单位,每个内存地址对应 1 字节。
- 一个字节最大值:
0xFF(十六进制)=255(十进制)=11111111(二进制)。
1.3 字(Word)
- 字的大小取决于 CPU 架构:
- 16 位系统:1 Word = 2 Byte
- 32 位系统(x86):1 Word = 4 Byte(32 bit)
- 64 位系统(x86-64):1 Word = 8 Byte(64 bit)
- C 语言数据类型大小(在 64 位系统上):
| C 类型 | 大小(字节) | 说明 |
|---|---|---|
char | 1 | 单字符 |
short | 2 | 短整型 |
int | 4 | 整型 |
long | 8 | 长整型 |
float | 4 | 单精度浮点数 |
double | 8 | 双精度浮点数 |
char *(指针) | 8 | 64 位地址 |
二、多字节数据的内存存储:字节序
2.1 小端法(Little-Endian)
小、低、低:低位字节存在低地址处。
x86 / x86-64 系统均采用小端法。
例:整数 0x12345678 在内存中(起始地址 0x0100):
| 地址 | 内容 |
|---|---|
| 0x0100 | 0x78(最低有效字节) |
| 0x0101 | 0x56 |
| 0x0102 | 0x34 |
| 0x0103 | 0x12(最高有效字节) |
2.2 大端法(Big-Endian)
高位字节存在低地址处,顺序与人类书写习惯一致。
- 常见于网络协议(TCP/IP)、SPARC、PowerPC 等架构。
| 地址 | 内容 |
|---|---|
| 0x0100 | 0x12(最高有效字节) |
| 0x0101 | 0x34 |
| 0x0102 | 0x56 |
| 0x0103 | 0x78 |
实际意义:跨平台网络通信时需注意字节序转换,C 中可用
htonl()/ntohl()等函数处理。
三、寄存器
寄存器是 CPU 内部速度最快的存储单元,直接参与运算,访问延迟约 1 个时钟周期(远快于内存的数百个时钟周期)。
3.1 寄存器的用途
- 存储整型数据和指针(地址)
- 存储运算的中间结果
- 记录程序执行状态(如 PC 指针、标志位)
3.2 x86(32 位)通用寄存器
32 位 CPU 包含 8 个 32 位通用寄存器:
| 寄存器 | 全称 | 常见用途 |
|---|---|---|
%eax | Accumulator | 返回值、算术运算 |
%ecx | Counter | 循环计数 |
%edx | Data | I/O、乘除法辅助 |
%ebx | Base | 基址(被调者保存) |
%esi | Source Index | 字符串/数组源地址 |
%edi | Destination Index | 字符串/数组目标地址 |
%esp | Stack Pointer | 栈顶指针 |
%ebp | Base Pointer | 栈帧基址 |

3.3 x86-64(64 位)通用寄存器
64 位扩展后,寄存器名称以 r 开头,并新增 8 个寄存器,共 16 个 64 位寄存器:
| 64 位 | 32 位低位 | 16 位低位 | 8 位低位 | 用途 |
|---|---|---|---|---|
%rax | %eax | %ax | %al | 返回值 |
%rbx | %ebx | %bx | %bl | 被调者保存 |
%rcx | %ecx | %cx | %cl | 第4参数 |
%rdx | %edx | %dx | %dl | 第3参数 |
%rsi | %esi | %si | %sil | 第2参数 |
%rdi | %edi | %di | %dil | 第1参数 |
%rsp | %esp | %sp | %spl | 栈顶指针 |
%rbp | %ebp | %bp | %bpl | 栈帧基址 |
%r8~%r15 | — | — | — | 第5~8参数等 |
记忆要点(函数参数传递顺序):
%rdi,%rsi,%rdx,%rcx,%r8,%r9,超出6个参数则通过栈传递。
3.4 特殊寄存器
| 寄存器 | 说明 |
|---|---|
%rip | 程序计数器(PC),指向下一条指令地址 |
EFLAGS / RFLAGS | 条件码寄存器(ZF 零标志、SF 符号标志、OF 溢出标志、CF 进位标志) |
注意:“PC中存放的是下一条指令的地址”这一机制是绝大多数机器底层通用的原则,在当前指令从内存中通过“取指”操作被加载到cpu内之后,PC中的地址值就会自增为下一条指令的地址,方便CPU更加快捷的进行连续取指操作,从而为流水线(pipeline)操作奠定基础。
3.5 VSPM 原型机寄存器分工
| 寄存器 | 职责 |
|---|---|
| R0 | 默认存储 / 输出结果 |
| R1, R2 | 输入 / 输出操作数 |
| R3 | 存储跳转用指令地址,便于 CPU 快速访问 |
| G | 条件判断寄存器,控制是否跳转 |
3.6 AT&T 汇编:指令操作数大小后缀
在 x86-64 AT&T 汇编(GCC / GDB 默认使用)中,指令名称后缀用于明确操作数的字节宽度,避免歧义:
| 后缀 | 全称 | 大小 | 对应 C 类型(典型) | 示例指令 |
|---|---|---|---|---|
b(byte) | Byte | 1 字节 = 8 bit | char | movb $0, %al |
w(word) | Word | 2 字节 = 16 bit | short | movw %ax, %bx |
l(long) | Long | 4 字节 = 32 bit | int | movl %eax, %ebx |
q(quad) | Quad | 8 字节 = 64 bit | long / 指针 | movq %rax, %rbx |
⚠️ 注意:AT&T 中
l后缀对应 32 位(4 字节),而非 64 位。这与 Intel 文档中 "DWORD" 的叫法一致,勿与 C 语言long(64位)混淆。
记忆口诀:b=1、w=2、l=4、q=8(单位:字节)
使用规则
- 后缀直接加在操作码后:
mov→movb/movw/movl/movq - 当操作数是寄存器时,GAS 可从寄存器名推断大小(如
%eax→ 32 位),但显式写后缀是更好的习惯。 cmp、add、sub等算术指令同理:cmpl,addq,subw…
# 示例:将 int 类型变量移动到寄存器(4字节,用 l 后缀)
movl -4(%rbp), %eax # 从栈上读取 4 字节 int
# 示例:字节操作(1字节,用 b 后缀)
movb $0x41, %al # 将字符 'A' 放入 al(1 字节)
# 示例:64位指针操作(8字节,用 q 后缀)
movq %rsp, %rbp # 复制栈指针(8 字节)
四、冯·诺伊曼体系结构
冯·诺伊曼结构将程序和数据统一存储在同一内存中,CPU 顺序取指执行。
flowchart LR
subgraph CPU[中央处理器(CPU)]
CU[控制器]
ALU[运算器]
end
IN[输入设备]
OUT[输出设备]
MEM[存储器]
%% ===== 数据流 =====
IN -- 数据 --> MEM
IN -- 数据 --> ALU
ALU -- 数据 --> MEM
MEM -- 数据 --> OUT
ALU -- 数据 --> OUT
%% ===== 指令流(从存储器到控制器)=====
MEM -- 指令 --> CU
%% ===== 控制命令(控制器发出)=====
CU -- 控制 --> MEM
CU -- 控制 --> ALU
CU -- 控制 --> IN
CU -- 控制 --> OUT
%% 数据流:蓝色
linkStyle 0,1,2,3,4 stroke:#1f77b4,stroke-width:2px
%% 指令流:红色
linkStyle 5 stroke:#d62728,stroke-width:2px
%% 控制命令:绿色
linkStyle 6,7,8,9 stroke:#2ca02c,stroke-width:2px
五大核心组成:
- 运算器(ALU):负责算术和逻辑运算
- 控制器(CU):读取并解析指令,发出控制信号
- 存储器(Memory):统一存放指令和数据(程序存储思想)
- 输入设备:键盘、鼠标等
- 输出设备:显示器、打印机等
现代改进:哈佛架构将指令存储和数据存储分离(如 ARM 的 L1 Cache),提升并行度。
五、原型机系统架构

六、C 语言编译流程
.c → .i → .s → .o → 可执行文件(.out / ELF)
源文件.c ──预处理──► .i ──编译──► .s ──汇编──► .o ──链接──► a.out
6.1 各阶段详解
| 阶段 | 工具 | 输入 | 输出 | 主要工作 |
|---|---|---|---|---|
| 预处理(Preprocessing) | cpp | .c | .i | 处理 #include、#define 宏展开、条件编译 |
| 编译(Compilation) | cc1 | .i | .s | 词法→语法→语义分析,生成汇编代码 |
| 汇编(Assembly) | as | .s | .o | 将汇编指令翻译为机器码,生成可重定位目标文件 |
| 链接(Linking) | ld | .o + 库 | a.out | 合并多个目标文件,解析符号引用,输出可执行文件 |
6.2 GCC 编译阶段命令示例
# 只做预处理,查看宏展开结果
gcc -E hello.c -o hello.i
# 编译到汇编(可读的 .s 文件)
gcc -S hello.c -o hello.s
# 编译到目标文件(二进制)
gcc -c hello.c -o hello.o
# 完整编译链接
gcc hello.c -o hello
# 查看目标文件的符号表
objdump -d hello.o
6.3 链接:静态链接 vs 动态链接
| 静态链接 | 动态链接 | |
|---|---|---|
| 时机 | 编译时合并 | 运行时加载 |
| 文件扩展名 | .a(归档库) | .so(共享库) / .dll |
| 可执行文件大小 | 较大 | 较小 |
| 更新方式 | 需重新编译 | 替换库文件即可 |
| 典型示例 | libc.a | libc.so |
七、存储器层次结构(Memory Hierarchy)
计算机存储系统从快到慢、从小到大:
CPU 寄存器
↓ (~1 cycle)
L1 Cache(~4 cycles,几十 KB)
↓ (~10 cycles)
L2 Cache(~10 cycles,几百 KB)
↓ (~40 cycles)
L3 Cache(~40 cycles,几十 MB)
↓ (~200 cycles)
主内存 RAM(几 GB)
↓ (~10,000,000 cycles)
磁盘 / SSD(几百 GB~几 TB)
↓
网络存储(云端)
局部性原理是 Cache 设计的基础:
- 时间局部性:最近访问过的数据很可能再次被访问(如循环变量)
- 空间局部性:访问某数据后,其相邻数据也可能被访问(如数组遍历)
八、指令集基础(VSPM 原型机)
8.1 指令格式
一条典型指令包含:操作码(Opcode) + 操作数(Operand)
| 操作码(4 bit)| 目标寄存器(2 bit)| 源寄存器或立即数(... bit)|
8.2 常见指令类型
| 类型 | 示例(x86 风格) | 说明 |
|---|---|---|
| 数据传送 | MOV dst, src | 寄存器/内存间数据移动 |
| 算术运算 | ADD, SUB, MUL, DIV | 整数四则运算 |
| 逻辑运算 | AND, OR, XOR, NOT | 位运算 |
| 移位 | SHL, SHR, SAR | 左移/逻辑右移/算术右移 |
| 比较 | CMP a, b | 计算 a-b,结果写入标志位 |
| 跳转 | JMP, JE, JNE, JG | 无条件 / 条件跳转 |
| 调用返回 | CALL, RET | 函数调用与返回 |
| 栈操作 | PUSH, POP | 压栈/弹栈 |
九、汇编语言基础(x86-64 AT&T)
本节是“第六章 C 编译流程”中
.s汇编文件的延伸,帮助读懂gcc -S的输出。
9.1 AT&T 语法约定
GCC / GDB 默认使用 AT&T 汇编语法,有以下几点基本约定:
- 寄存器前加
%,如%rax、%rbp - 立即数(常量)前加
$,如$42、$0xFF - 操作数顺序:
src → dst(源在左,目标在右) - 指令后缀表示操作宽度:
b(1B) /w(2B) /l(4B) /q(8B)
数制写法说明
汇编源码中的数字可以用不同进制书写,只是人类表达习惯的差异,汇编器会统一转换为二进制,CPU 拿到的永远是二进制:
| 写法 | 进制 | 示例 | 等价十进制 |
|---|---|---|---|
$42 | 十进制(默认) | $42 | 42 |
$0x2A | 十六进制(0x 前缀) | $0xFF | 255 |
$052 | 八进制(0 前缀,少见) | $010 | 8 |
movl $42, %eax # eax = 42(十进制写法)
movl $0x2A, %eax # eax = 42(十六进制写法,完全等价)
关于地址计算:不需要手动换算进制。地址
0x601034和6295604是同一个地址,汇编器和调试器都会处理好转换。GDB 默认用十六进制显示地址和内存内容,读起来更紧凑(一个字节恰好两位 hex)。偏移量如-8(%rbp)中的-8用十进制写,是因为它是小数字,十进制更直观。进制只影响源码可读性,不影响实际运算。
9.2 常见数据传送指令
# 立即数 → 寄存器
movl $42, %eax # eax = 42
# 寄存器 → 寄存器
movq %rax, %rbx # rbx = rax
# 内存(栈帧相对寻址)→ 寄存器
movl -8(%rbp), %eax # eax = *(rbp - 8)
# 寄存器 → 内存
movq %rax, (%rsp) # *rsp = rax
9.2.5 lea vs mov:必考区别 ⚠️
lea(Load Effective Address)和 mov 是汇编中最容易混淆的指令对,也是考试高频考点。
核心区别一句话
| 指令 | 功能 | 会访问内存吗? |
|---|---|---|
mov | 把内存地址里的数据搬运到目标 | 会(有括号时) |
lea | 把地址本身的计算结果放进目标 | 不会 |
对比示例
movl -8(%rbp), %eax # ① 取内存里的值:eax = *(rbp - 8)
leaq -8(%rbp), %rax # ② 计算地址本身:rax = rbp - 8(不访问内存!)
用 C 来理解:
int x = 42;
// ① movl -8(%rbp), %eax → eax = x; (读 x 的值)
// ② leaq -8(%rbp), %rax → rax = &x; (取 x 的地址)
lea 的两大用途
用途一:取变量地址(等价 C 的 &)
leaq -8(%rbp), %rdi # rdi = &x,常用于把地址作为参数传入函数
用途二:被编译器用作"带乘法的加法"(算术快捷键)
lea 的地址公式 base + index × scale + offset 本质是一个乘加运算,编译器经常借用它来做整数乘法优化,而不是真的要用那个地址:
leaq (%rax, %rax, 2), %rax # rax = rax + rax*2 = rax * 3
leaq (%rax, %rax, 4), %rax # rax = rax * 5
salq $3, %rax # rax <<= 3,即 rax * 8(这是移位,不是 lea)
等价 C:
x = x * 3;会被编译器变成leaq (%rax,%rax,2), %rax以避免慢速的imul乘法指令。
常见考题思路
# 给定:rbp = 0x7fff0010,内存[0x7fff0008] = 99
movl -8(%rbp), %eax # eax = ? → 去内存 0x7fff0008 取值 → eax = 99
leaq -8(%rbp), %rax # rax = ? → 只算地址 → rax = 0x7fff0008
判断口诀:看到
lea,不管括号写什么,结果都是那个地址数字本身,不是地址里的内容。mov加括号才是去内存取值。
深层理解:寄存器不区分「地址」还是「整数」
有一个常见困惑:lea 把一个「地址」写进了寄存器,那取用这个寄存器时,不应该是去那个地址取内存值吗?
不会。 因为寄存器里只有比特,它不知道自己存的是地址还是整数。由地址变成内存访问,必须有指令显式用括号触发,不会自动发生。
lea 做的事分三步,在第②步就停住了:
| 步骤 | mov (%rax), %eax | lea (%rax,...), %eax |
|---|---|---|
| ① 计算括号内的数值 | ✅ | ✅ |
| ② 去内存取那个地址的内容 | ✅ 做 | ❌ 跳过,直接停 |
| ③ 写进目标寄存器 | 写的是内存里的值 | 写的是①算出来的数字本身 |
所以 int t = a + 2*b:
leal (%ecx, %edx, 2), %eax # eax = ecx + edx*2
movl (%ecx, %edx, 2), %eax # eax = *(ecx + edx*2) ← 去那个地址取内存里的值
eax 里就存着 a + 2b 这个整数结果——没有人去内存取东西,括号只是借用了 CPU 地址计算硬件来做乘加,和「指针」完全无关。
9.3 变量与内存地址:汇编眼中的「变量」
在 C 语言里我们自然地使用变量名,但汇编和 CPU 只认内存地址,不认名字。理解这个映射关系是读懂汇编的基础。
标量变量(int、long…)
C 中一个 int x = 42 在汇编里的实质:
- 编译器在栈帧中为
x分配一块 4 字节空间,假设地址是rbp - 8 - 变量名
x→ 地址rbp - 8(一个标签,编译完就消失) - 对
x赋值 → 往那个地址写数据
movl $42, -8(%rbp) # x = 42,即往地址 (rbp-8) 写入 42
movl -8(%rbp), %eax # 读取 x 的值到 eax
结论:标量变量的「值」就存在它对应的地址里,变量名只是地址的别名。
数组变量(arr[])
这是最容易迷惑的地方:
int arr[4] = {10, 20, 30, 40};
int *p = arr; // p 赋的是什么?
arr 这个名字在汇编中直接就是数组第一个元素的地址(首地址),而不是某个存了地址的变量。
内存布局(假设 arr 首地址 = 0x601030):
| 地址 | 内容 | 数组元素 |
|---|---|---|
| 0x601030 | 10 | arr[0] |
| 0x601034 | 20 | arr[1] |
| 0x601038 | 30 | arr[2] |
| 0x60103C | 40 | arr[3] |
leaq arr(%rip), %rdi # 把 arr 的首地址(0x601030)加载进 rdi
movl (%rdi), %eax # eax = arr[0] = 10
movl 8(%rdi), %eax # eax = arr[2] = 30(偏移 8 字节)
lea指令(Load Effective Address)专门用来计算并加载地址本身,不访问内存:
lea -8(%rbp), %rax→rax = rbp - 8(rax 里存的是地址,没有取内存内容)
指针变量(int *p)
指针变量本身在内存里也占一块空间(64 位系统下 8 字节),里面存的是另一个变量的地址:
int x = 42;
int *p = &x; // p 里存的是 x 的地址
内存布局(rbp-8 是 x,rbp-16 是 p):
| 地址 | 内容 |
|---|---|
| rbp-8 | 42 |
| rbp-16 | (rbp-8 的值) |
movl $42, -8(%rbp) # x = 42
leaq -8(%rbp), %rax # rax = &x(x 的地址)
movq %rax, -16(%rbp) # p = &x(把地址存进 p)
movq -16(%rbp), %rax # 读出 p(得到地址)
movl (%rax), %eax # *p:用 p 存的地址去取值,得到 42
一句话总结
| C 表达式 | 汇编含义 |
|---|---|
x | 读取变量 x 所在地址的内容 |
&x | x 所在的内存地址本身(用 lea 取得) |
arr / arr[0] | 数组首地址(arr 名字就是首地址) |
arr[i] | 首地址 + i × 元素大小 处的内容(变址寻址) |
*p | 先取 p 的值(一个地址),再去那个地址取内容(间接寻址) |
核心直觉:汇编里没有「变量」概念,只有地址和地址里的内容。C 的每一个变量操作,最终都是对某个内存地址的读或写。
指针的底层本质
上面已经知道指针变量"存的是地址",但从底层视角还有更多值得深挖的东西。
指针 = 一个整数
在机器层面,指针就是一个无符号整数,宽度等于地址总线位数:
| 系统 | 指针宽度 | 地址空间 |
|---|---|---|
| 32 位 | 4 字节 | 0 ~ 0xFFFFFFFF(4 GB) |
| 64 位 | 8 字节 | 0 ~ 0xFFFFFFFFFFFFFFFF(理论 16 EB,实际通常 48 位 = 256 TB) |
# 64 位系统中,指针操作全部使用 q(8字节)后缀
movq %rax, -16(%rbp) # 存指针:8 字节
movq -16(%rbp), %rax # 读指针:8 字节
CPU 不知道寄存器里存的是"地址"还是"整数"——它只看到 64 位的二进制。是指针还是整数,完全由指令决定:
movq (%rax), %rbx把 rax 当地址用,addq %rax, %rbx把 rax 当整数用。
但编译器知道指针的类型
虽然机器层面指针只是整数,但 C 编译器为每个指针记录了它指向什么类型,这影响两件事:
- 解引用时读多少字节:
*p读sizeof(*p)字节 - 指针算术的步长:
p + 1实际加sizeof(*p)字节
| 指针类型 | sizeof(*p) | p + 1 实际偏移 |
|---|---|---|
char * | 1 | +1 字节 |
int * | 4 | +4 字节 |
long * | 8 | +8 字节 |
double * | 8 | +8 字节 |
int arr[4] = {10, 20, 30, 40};
int *p = arr; // p = 0x601030
p + 1; // = 0x601034(不是 0x601031!)
p + 2; // = 0x601038
对应汇编(变址寻址):
# p 在 %rdi,i 在 %rsi
movl (%rdi, %rsi, 4), %eax # eax = p[i] = *(p + i*4)
# ↑ scale = sizeof(int) = 4
p + i的底层实现就是(char *)p + i * sizeof(*p),编译器在生成汇编时自动乘上元素大小。
指针运算的规则与限制
| 运算 | 合法? | 底层含义 | 结果类型 |
|---|---|---|---|
p + n | ✅ | 地址 + n × 元素大小 | 同类型指针 |
p - n | ✅ | 地址 - n × 元素大小 | 同类型指针 |
p - q(同类型) | ✅ | (p地址 - q地址) / sizeof(*p) = 元素间距 | ptrdiff_t(整数) |
p + q | ❌ | 无意义(两个地址相加没有物理含义) | 编译错误 |
p * n / p / n | ❌ | 无意义 | 编译错误 |
int a[5] = {0, 1, 2, 3, 4};
int *p = &a[1], *q = &a[4];
q - p; // = 3(不是 12!编译器自动除以 sizeof(int))
对应汇编:
# q 在 %rax, p 在 %rcx
subq %rcx, %rax # rax = q地址 - p地址(字节差 = 12)
sarq $2, %rax # rax >>= 2,即 ÷4 = 3(算术右移,等价整除 sizeof(int))
内存对齐(Alignment)
CPU 访问内存时,硬件要求数据地址是其大小的整数倍:
| 数据类型 | 大小 | 对齐要求 | 合法地址 |
|---|---|---|---|
char | 1 | 1 字节对齐 | 任意地址 |
short | 2 | 2 字节对齐 | 0, 2, 4, 6 … |
int | 4 | 4 字节对齐 | 0, 4, 8, 12 … |
long / 指针 | 8 | 8 字节对齐 | 0, 8, 16, 24 … |
为什么要对齐?
- 对齐的访问可以在一次总线传输中完成
- 未对齐的访问可能需要两次读取 + 拼接,甚至在某些架构上直接报错(Bus Error)
结构体中的对齐与填充:
struct S {
char a; // 1 字节 偏移 0
// 3 字节填充(padding),使 b 对齐到 4 的倍数
int b; // 4 字节 偏移 4
char c; // 1 字节 偏移 8
// 7 字节填充,使整个结构体大小是 8 的倍数(最大成员对齐)
};
// sizeof(struct S) = 16,不是 1+4+1=6 !
| 偏移 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | … | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 内容 | a | pad | pad | pad | b | b | b | b | c | pad | … | pad |
优化技巧:把结构体成员按大小从大到小排列,可以减少填充浪费。
数组与指针的等价性(底层视角)
在 C 语言中,a[i] 和 *(a + i) 完全等价。底层原因是编译器对两者生成完全相同的汇编:
int a[4] = {10, 20, 30, 40};
a[2]; // 方式一
*(a + 2); // 方式二
2[a]; // 方式三(合法!因为加法交换律:*(2 + a))
三种写法生成的汇编都是:
movl 8(%rdi), %eax # *(a + 2*4) = *(a + 8)
但数组名 ≠ 指针变量:
数组名 arr | 指针变量 int *p | |
|---|---|---|
| 本质 | 编译期常量地址(不可修改) | 一个变量,占 8 字节,内容可变 |
sizeof | 整个数组大小(如 4*4=16) | 指针大小(永远是 8) |
&arr | 与 arr 值相同,但类型是 int (*)[4] | &p 是 p 变量自己的地址 |
| 赋值 | arr = ... ❌ 非法 | p = ... ✅ 合法 |
# arr 是数组名 → 编译器直接把地址嵌入指令
leaq arr(%rip), %rdi # rdi = arr 的地址(编译时已知)
# p 是指针变量 → 要从内存读出 p 里存的值
movq -16(%rbp), %rdi # rdi = p 的值(运行时从栈上读)
字符串的底层表示
C 中没有"字符串类型",字符串就是 char 数组 + 末尾的 \0(空终止符,值为 0x00):
char s[] = "Hi!";
// 等价于:char s[4] = {'H', 'i', '!', '\0'};
内存布局:
| 地址 | 字节值 | ASCII |
|---|---|---|
| 0x601030 | 0x48 | H |
| 0x601031 | 0x69 | i |
| 0x601032 | 0x21 | ! |
| 0x601033 | 0x00 | \0(终止符) |
strlen 的底层实现(简化版):从首地址开始逐字节扫描,直到遇到 0x00:
# rdi = 字符串首地址
strlen_loop:
cmpb $0, (%rdi) # 当前字节 == 0?
je strlen_done # 是 → 结束
incq %rdi # 否 → 指针 +1
jmp strlen_loop
strlen_done:
# rdi - 原始首地址 = 字符串长度
字符串字面量存在哪?
char *p = "hello"; // p 指向 .rodata 段(只读数据段)
char s[] = "hello"; // s 是栈上的数组,内容从 .rodata 复制过来
char *p = "hello" | char s[] = "hello" | |
|---|---|---|
| 字符串位置 | .rodata(只读段) | 栈上(可写) |
| 可否修改 | p[0] = 'H' ❌ 段错误 | s[0] = 'H' ✅ 合法 |
sizeof | 8(指针大小) | 6(含 \0 的数组大小) |
# char *p = "hello"
leaq .LC0(%rip), %rax # .LC0 是 "hello" 在 .rodata 段的标签
movq %rax, -8(%rbp) # p = 字符串的地址
# char s[] = "hello"
movl $0x6c6c6568, -14(%rbp) # 把 "hell" 四字节直接写到栈上
movw $0x006f, -10(%rbp) # 把 "o\0" 两字节写到栈上
关键区别:字符串字面量(
"hello")是放在只读数据段的,用指针指过去就不能改;用数组赋值则是复制一份到栈上,可以随意修改。这是 C 语言面试和 debug 的经典坑。
指针的危险行为(Undefined Behavior)
C 语言把内存管理交给程序员,编译器和 CPU 都不会主动阻止你对指针的误操作。以下这些行为属于未定义行为(UB)——编译器可以做任何事,包括"看起来正常运行"直到某天突然崩溃。
① 悬空指针(Dangling Pointer)
指针指向的内存已经被释放或超出作用域,但指针本身还保留着那个旧地址。
// 场景一:free 之后继续使用
int *p = malloc(sizeof(int));
*p = 42;
free(p); // 内存已归还给系统
*p = 100; // ❌ 悬空!这块内存可能已经分配给别人了
// 场景二:返回局部变量的地址
int *bad() {
int x = 42;
return &x; // ❌ x 在函数返回后被销毁,地址失效
}
int *p = bad();
*p; // 悬空指针解引用,结果不可预测
底层发生了什么:
栈帧(bad 函数) bad 返回后
┌──────────┐ ┌──────────┐
│ x = 42 │ ← p 指向 │ ?????? │ ← p 还指向这里
└──────────┘ └──────────┘
这块栈空间已被后续函数调用覆盖
防御:
free(p)之后立即p = NULL,养成习惯。
② 空指针解引用(NULL Pointer Dereference)
int *p = NULL; // p = 0x0000000000000000
*p = 42; // ❌ 去地址 0 写入 → 段错误(Segmentation Fault)
底层原因:操作系统故意不映射地址 0 附近的页面,任何对这个区域的访问都会触发页错误(Page Fault),内核把它转化为 SIGSEGV 信号发给进程。
movq $0, %rax # rax = NULL = 0
movl $42, (%rax) # 去地址 0 写入 → 触发硬件异常
这就是为什么段错误的错误地址经常在
0x0附近——都是 NULL 解引用导致的。
③ 野指针(Wild Pointer)
指针从未被初始化,里面是栈上的垃圾值,指向一个随机地址。
int *p; // 未初始化!p 的值是栈上残留的垃圾数据
*p = 42; // ❌ 写入一个随机地址,可能破坏任何数据
与悬空指针的区别:悬空指针曾经有效(后来失效),野指针从来就没有效过。
④ 缓冲区溢出(Buffer Overflow)
指针运算越过数组边界,读写不属于你的内存——这是安全漏洞的第一大来源。
char buf[8];
strcpy(buf, "This string is way too long!");
// ❌ 写入远超 8 字节,覆盖了栈上的返回地址等关键数据
栈上的破坏效果:
| 偏移 | 正常内容 | 溢出后 |
|---|---|---|
| buf+0~7 | 用户数据(8字节) | "This str" |
| buf+8~15 | 保存的 rbp | "ing is w" ← 被覆盖! |
| buf+16~23 | 返回地址 | "ay too l" ← 被覆盖! |
返回地址被覆盖后,
ret指令会跳转到一个攻击者控制的地址——这就是经典的栈溢出攻击原理。现代系统用 Stack Canary、ASLR、NX bit 等机制来防御。
⑤ 类型双关(Type Punning)
通过强制类型转换让同一块内存被当作不同类型来解读:
float f = 3.14f;
int *ip = (int *)&f; // 把 float 的地址当 int 指针
printf("%d\n", *ip); // 输出 1078523331(float 3.14 的 IEEE 754 位模式)
| 视角 | 同一块 4 字节内存 |
|---|---|
float | 3.14 |
int(位模式) | 0x4048F5C3 = 1078523331 |
这在底层是合法的(内存就是一堆字节),但在 C 标准中属于 UB(违反严格别名规则)。安全的做法是用
memcpy或union。
⚠️ 常见指针 Bug 速查
| Bug | 症状 | 常见原因 |
|---|---|---|
| 段错误(SIGSEGV) | 程序崩溃 | NULL 解引用、访问已释放/未映射内存 |
| 数据莫名被改 | 逻辑出错 | 缓冲区溢出覆盖了相邻变量 |
| 每次运行结果不同 | 不可复现 | 悬空指针 / 野指针读到不同的垃圾值 |
| double free 崩溃 | 程序崩溃 | 同一块内存 free 了两次 |
| 内存泄漏 | 内存持续增长 | malloc 后忘记 free(指针丢失) |
9.4 操作数寻址模式
🔑 核心判断规则:有括号 = 去内存取值;没有括号 = 值本身
写法 读到的是 类比 C $42常量值 42 42%raxrax 里存的值 变量值 (%rax)去内存,地址=rax,取那里的内容 *rax-8(%rbp)去内存,地址=rbp-8,取那里的内容 *(rbp-8)唯一例外:
lea指令写了括号却只算地址、不取内存:movl -8(%rbp), %eax # 取内存值:eax = *(rbp-8) → 读变量 leaq -8(%rbp), %rax # 只取地址:rax = rbp-8 → &变量
汇编指令的操作数有三类来源:立即数、寄存器、内存。内存寻址又按地址计算方式细分为多种模式。
① 立即数寻址(Immediate Addressing)
操作数直接是一个编码在指令内的常量,不需要任何内存访问。
movl $42, %eax # eax = 42(十进制立即数)
movq $0xFF, %rbx # rbx = 255(十六进制立即数)
addl $1, %ecx # ecx += 1
特点:速度最快,值在编译时固定,无法动态修改。用于常量赋值、循环步长等场合。
② 寄存器寻址(Register Addressing)
操作数存放在寄存器中,直接读写寄存器,无需访问内存。
movq %rax, %rbx # rbx = rax(寄存器 → 寄存器)
addl %ecx, %eax # eax += ecx
特点:访问延迟约 1 个时钟周期,是最常用的操作数形式。寄存器就是 CPU 内部的"变量"。
③ 绝对寻址(Absolute / Direct Addressing)
操作数的内存地址是一个固定数值,硬编码在指令里。
movl (0x601030), %eax # eax = *(0x601030),读取全局变量
movb %al, (0x601030) # *(0x601030) = al
特点:地址在链接时确定,常用于全局变量、静态变量。64 位模式下地址空间太大,编译器更倾向于用 RIP 相对寻址代替。
④ 间接寻址(Register Indirect Addressing)
寄存器中保存的是内存地址,通过该地址读写内存——等价于 C 中的指针解引用 *p。
movq (%rax), %rbx # rbx = *rax(rax 是指针,读取其指向的值)
movl %ecx, (%rsp) # *rsp = ecx(写入 rsp 指向的地址)
特点:最基本的指针操作。
(%rax)中的括号表示“把 rax 里的值当地址去取内存”。
⑤ 基址 + 偏移寻址(Base + Displacement)
在间接寻址的基础上加一个常量偏移,地址 = base + offset。
这是访问栈帧局部变量的核心方式。是直接在内存地址上的偏移量。
movl -8(%rbp), %eax # eax = *(rbp - 8),访问局部变量
movq 8(%rsp), %rbx # rbx = *(rsp + 8),访问上层参数
movb 1(%rdi), %cl # cl = *(rdi + 1),访问结构体字段
特点:
%rbp是栈帧基址,负偏移访问局部变量,正偏移访问传入的参数。是函数体中出现频率最高的寻址方式。
⑥ 变址寻址(Indexed / Scaled Addressing)
本质:用两个寄存器里存的值,按公式算出一个内存地址,再去那个地址取/写数据。不是寄存器的地址相加,寄存器本身就不在内存里。
有效地址 = base的值 + index的值 × scale
- base(第 1 个寄存器):数组起始地址,即 C 里的
arr - index(第 2 个寄存器):元素下标,即
i - scale:每个元素的字节数,只能是
1, 2, 4, 8
movl (%rdi, %rsi, 4), %eax
拆解(假设 rdi = 0x601030,rsi = 3):
- 有效地址 =
0x601030 + 3 × 4=0x60103C - 去内存地址
0x60103C取 4 字节,放进eax
等价 C:eax = arr[3](rdi = arr,rsi = 3,4 = sizeof(int))
movq (%rbx, %rcx, 8), %rdx # rdx = arr[rcx](long 数组,每元素 8 字节)
⑦ 完整通用寻址(Base + Index × Scale + Displacement)
AT&T 完整格式:offset(base, index, scale),地址 = base + index × scale + offset
movl 8(%rbx, %rcx, 4), %eax # eax = *(rbx + rcx*4 + 8)
等价 C:
struct Foo *arr = (struct Foo *)rbx;
eax = arr[rcx].field; // 其中 field 的偏移量为 8,int 类型(4字节)
特点:结合了基址、比例变址和偏移,可以一条指令访问结构体数组中某个字段。
📌 寻址模式综合对比
| 寻址模式 | AT&T 写法 | 等价 C | 地址/值计算 | 典型场景 |
|---|---|---|---|---|
| 立即数 | $42 | 42(值本身) | 值在指令中 | 常量赋值 |
| 寄存器 | %rax | rax | 寄存器中的值 | 通用运算 |
| 间接 | (%rax) | *rax | M[rax] | 指针解引用 |
| 基址+偏移 | -8(%rbp) | *(rbp-8) | M[rbp-8] | 局部变量 |
| 绝对 | (0x601030) | *(int*)0x601030 | M[0x601030] | 全局变量 |
| 变址 | (%rdi,%rsi,4) | arr[rsi] | M[rdi+rsi×4] | 数组访问 |
| 完整通用 | 8(%rbx,%rcx,4) | arr[rcx].field | M[rbx+rcx×4+8] | 结构体数组 |
💡 理解要点:立即数和寄存器寻址的操作数就是值本身;其余所有带括号的形式都表示去内存里取值,括号里算出来的是地址,不是值。掌握这一点,读任何一行
mov指令都不会迷失。
9.5 算术与逻辑指令
addl %ecx, %eax # eax += ecx
subl $1, %eax # eax -= 1
imull %edx, %eax # eax *= edx(有符号乘法)
andq %rbx, %rax # rax &= rbx
orq %rbx, %rax # rax |= rbx
xorq %rax, %rax # rax = 0(常用清零技巧)
salq $2, %rax # rax <<= 2(算术左移,等价 ×4)
sarq $1, %rax # rax >>= 1(算术右移,符号扩展)
9.6 栈(Stack)与 push / pop
栈是什么
首先,栈只是整块内存里的一个区域,和数据段、代码段共享同一片 RAM,只是划分了不同的用途:
内存(整块 RAM)
┌─────────────────┐ 高地址
│ 栈(Stack) │ ← 函数调用时在这里操作,push/pop 只在这块区域内
│ ↓ │ 向下增长(rsp 减小 = 压栈)
│ ~~空闲~~ │
│ ↑ │
│ 堆(Heap) │ ← malloc / new 动态分配在这
├─────────────────┤
│ 数据段 │ ← 全局变量、static 变量
├─────────────────┤
│ 代码段(Text) │ ← 程序的机器码指令本身
└─────────────────┘ 低地址
push/pop 是在栈这一个区域内操作(不是在不同区域之间搬运):
push= 往栈顶再叠一层;pop= 从栈顶取走一层。
栈的特点是只能动顶部(LIFO),数据段和堆可以通过地址随机访问任意位置。
栈遵循 后进先出(LIFO) 原则,用于:
- 保存函数的局部变量
- 保存返回地址(
call指令压入,ret弹出) - 临时保存寄存器值(被调者需要保护的寄存器)
关键特性:栈向低地址方向增长
在栈中存入0x12345678:
地址 内容 说明
84 │ 0x12 │ ← 最高地址(最先被写入,最后弹出)
83 │ 0x34 │
82 │ 0x56 │
81 │ 0x78 │ ← rsp 指向这里(栈顶 = 最低地址)
高地址
↑ 旧帧(调用方)
│
│ ← %rbp(当前栈帧基址)
│ 局部变量 1 (rbp - 8)
│ 局部变量 2 (rbp - 16)
│
│ ← %rsp(栈顶指针,始终指向最新压入的数据)
低地址
%rsp始终指向栈顶(当前占用的最低地址)。压栈时rsp减小,弹栈时rsp增大——和直觉相反,因为栈是「向下长」的。
pushq:压栈
pushq src 等价于两步操作:
pushq %rax
# 等价于:
subq $8, %rsp # ① rsp -= 8(腾出空间,64位操作数占8字节)
movq %rax, (%rsp) # ② 把 rax 的值写入新的栈顶
压栈前: 压栈后:
rsp → [ 旧内容 ] rsp → [ rax的值 ] ← 新栈顶
[ 旧内容 ]
popq:弹栈
popq dst 等价于两步操作:
popq %rbx
# 等价于:
movq (%rsp), %rbx # ① 把栈顶的值读入 rbx
addq $8, %rsp # ② rsp += 8(释放栈顶空间)
弹栈前: 弹栈后:
rsp → [ 某个值 ] [ 某个值 ](内存还在,只是rsp不指这里了)
[ 其他 ] rsp → [ 其他 ]
弹栈后那块内存的数据并没有被清除,只是
rsp移走了,下次压栈会覆盖它。
push / pop 与数据段的关系
push / pop 的两端是寄存器 ↔ 栈,数据段在整个过程中完全不变。
以全局变量 int g = 42(在数据段)为例:
movl g(%rip), %eax # ① 从数据段读到寄存器;数据段 g 还是 42
pushq %rax # ② 寄存器 → 栈;数据段 g 还是 42
popq %rbx # ③ 栈 → 寄存器;数据段 g 还是 42
movl %ebx, g(%rip) # ④ 只有显式 mov 才能写回数据段
| 操作 | 谁变了 | 数据段变了吗 |
|---|---|---|
mov [内存→寄存器] | 寄存器 | ❌ 不变(只读) |
pushq %rax | 栈内容 + rsp 减小 | ❌ 不变 |
popq %rbx | 寄存器 rbx + rsp 增大 | ❌ 不变 |
mov [寄存器→内存] | 数据段 | ✅ 才会变 |
push 和 pop 只是把「寄存器里的值」在栈上复制一份,或者把栈上的值复制到寄存器,原始来源(数据段/内存)不受影响。数据段只有在显式用
mov 寄存器, 内存地址写回时才会改变。
常见用法:函数开头保存 / 结尾恢复
func:
pushq %rbp # 保存调用方的栈帧基址
movq %rsp, %rbp # 建立本函数的栈帧
subq $16, %rsp # 为局部变量腾出 16 字节空间
...
movq %rbp, %rsp # 恢复栈指针(释放局部变量空间)
popq %rbp # 恢复调用方栈帧基址
ret # 弹出返回地址,跳回调用方
9.7 条件跳转与 CMP
cmpl %ebx, %eax # 计算 eax - ebx,结果写入 EFLAGS,不修改寄存器
jl .Lless # 若 eax < ebx,跳转(Jump if Less)
jge .Lge # 若 eax >= ebx,跳转
je .Leq # 若 eax == ebx(ZF=1),跳转
常用条件跳转速查:
| 指令 | 含义 | 触发条件 |
|---|---|---|
je | 等于 | ZF = 1 |
jne | 不等 | ZF = 0 |
jl | 有符号小于 | SF ≠ OF |
jg | 有符号大于 | ZF = 0 且 SF = OF |
jb | 无符号小于 | CF = 1 |
ja | 无符号大于 | CF = 0 且 ZF = 0 |
9.6 函数调用约定(x86-64 System V ABI)
# 调用方(caller)
movl $10, %edi # 第1参数 → %rdi
movl $20, %esi # 第2参数 → %rsi
call add_two # 压入返回地址,跳转到 add_two
# 函数返回后,返回值在 %rax 中
# 被调用方(callee)
add_two:
pushq %rbp # 保存调用方栈帧基址
movq %rsp, %rbp # 建立新栈帧
movl %edi, %eax
addl %esi, %eax # eax = arg1 + arg2(返回值放入 rax)
popq %rbp # 恢复栈帧
ret # 弹出返回地址,跳回调用方
参数寄存器顺序(前6个整型/指针参数):
%rdi,%rsi,%rdx,%rcx,%r8,%r9;超出 6 个参数则通过栈传递。
十、GDB 基础操作手册
GDB(GNU Debugger)是配合 CSAPP 学习汇编的核心工具。下面按操作分类整理常用命令。
10.0 准备:编译时保留调试信息
gcc -g -O0 hello.c -o hello
# -g 保留符号信息(函数名、变量名、行号)
# -O0 关闭编译器优化,汇编与源码严格对应,便于调试
10.1 启动与退出
gdb ./hello # 加载可执行文件启动 GDB
gdb ./hello core # 加载 core dump 进行事后分析
(gdb) run # 从头运行程序(简写 r)
(gdb) run arg1 arg2 # 带命令行参数运行
(gdb) quit # 退出 GDB(简写 q)
10.2 断点管理
(gdb) break main # 在 main 函数入口设断点(简写 b)
(gdb) break hello.c:25 # 在源文件第 25 行设断点
(gdb) break *0x401234 # 在指定内存地址设断点(汇编调试首选)
(gdb) break func if x == 0 # 条件断点:仅当 x==0 时触发
(gdb) info breakpoints # 列出所有断点(简写 info b)
(gdb) disable 2 # 禁用编号 2 的断点(不删除)
(gdb) enable 2 # 启用编号 2 的断点
(gdb) delete 2 # 删除编号 2 的断点
(gdb) delete # 删除所有断点
10.3 执行控制
| 命令 | 简写 | 说明 |
|---|---|---|
run | r | 从头运行 |
continue | c | 继续运行到下一个断点 |
next | n | 单步(不进入函数调用) |
step | s | 单步(进入函数调用) |
nexti | ni | 汇编级单步(不进入 call) |
stepi | si | 汇编级单步(进入 call) |
finish | fin | 运行直到当前函数返回 |
until 30 | u 30 | 运行到第 30 行(跳出循环用) |
kill | k | 终止当前运行的程序 |
学汇编时优先用
ni/si,一条指令一条指令地走,配合info registers观察寄存器变化。
10.4 查看寄存器值
(gdb) info registers # 查看所有通用寄存器(简写 info reg)
(gdb) info registers rax rbp # 只看指定寄存器
(gdb) print $rax # 十进制显示 rax 值(简写 p)
(gdb) print/x $rax # 十六进制显示 rax
(gdb) print/d $rax # 十进制显示
(gdb) print/t $rax # 二进制显示
(gdb) print $rsp # 查看当前栈指针(地址值)
/x(hex)、/d(dec)、/t(binary)、/c(char)、/f(float)
10.5 查看变量 / 地址值
(gdb) print x # 查看变量 x 的值
(gdb) print &x # 查看变量 x 的地址
(gdb) print *ptr # 解引用指针 ptr
(gdb) print ptr # 查看指针本身的值(即地址)
(gdb) print arr[3] # 查看数组第 4 个元素
(gdb) print sizeof(int) # 查看类型大小
(gdb) display $rip # 每次停下自动显示 rip 值
(gdb) display $rax # 每次停下自动显示 rax
(gdb) undisplay 1 # 取消自动显示编号 1
10.6 查看内存值(examine)
命令格式:x/[数量][格式][大小] 地址
格式字母:
| 字母 | 含义 |
|---|---|
x | 十六进制 |
d | 十进制 |
u | 无符号十进制 |
s | 字符串(直到 \0) |
i | 反汇编为指令 |
c | 单字符 |
大小字母:
| 字母 | 含义 |
|---|---|
b | 1 字节 |
h | 2 字节(halfword) |
w | 4 字节(word) |
g | 8 字节(giant) |
# 查看内存中的原始字节
(gdb) x/4xb 0x601030 # 从 0x601030 起,4 个字节,十六进制
(gdb) x/8xw $rsp # 从 rsp 起,8 个 4字节,十六进制(看栈)
(gdb) x/4gx $rsp # 从 rsp 起,4 个 8字节,十六进制(64位栈帧)
# 查看内存中的字符串
(gdb) x/s $rdi # 把 rdi 指向的地址当字符串打印(常用!)
(gdb) x/s 0x402400 # 查看只读数据段中的字符串常量
# 查看内存中的整数
(gdb) x/d $rbp-8 # 以十进制看 rbp-8 处存的 int(局部变量)
(gdb) x/gd $rsp+16 # 以十进制看 rsp+16 处存的 long
常用组合:
x/s $rdi打印字符串参数;x/8gx $rsp看整个栈帧布局。
10.7 查看汇编代码
# 反汇编
(gdb) disassemble # 反汇编当前函数(简写 disas)
(gdb) disassemble main # 反汇编 main 函数
(gdb) disassemble /m main # 同时显示对应的 C 源码(混合模式)
(gdb) disassemble /r main # 同时显示机器码原始字节
# 从某地址开始反汇编若干条指令
(gdb) x/10i $rip # 从当前 rip 起反汇编 10 条指令
(gdb) x/5i 0x401234 # 从指定地址反汇编 5 条指令
# 设置汇编风格(AT&T 是默认)
(gdb) set disassembly-flavor intel # 切换为 Intel 风格
(gdb) set disassembly-flavor att # 切回 AT&T 风格
10.8 查看源码
(gdb) list # 显示当前位置附近的源码(简写 l)
(gdb) list main # 显示 main 函数的源码
(gdb) list hello.c:20 # 显示 hello.c 第 20 行附近
(gdb) list 15,30 # 显示第 15~30 行
注意:只有用
-g编译时才能看到源码,否则只能看汇编。
10.9 观察点(Watchpoint)
当某个变量/地址被读写时自动暂停,无需在代码里找修改点。
(gdb) watch x # 变量 x 被**写入**时暂停
(gdb) rwatch x # 变量 x 被**读取**时暂停
(gdb) awatch x # 变量 x 被读或写时暂停
(gdb) watch *0x601030 # 监视指定内存地址的写入
10.10 综合调试流程示例
以 CSAPP《深入理解计算机系统》的 Bomb Lab 为例:
$ gcc -g -O0 bomb.c -o bomb
$ gdb ./bomb
(gdb) break phase_1 # 在 phase_1 函数入口设断点
(gdb) run # 运行到断点
(gdb) disassemble # 查看 phase_1 的汇编,找关键指令
(gdb) x/s $rsi # rsi 常常指向比较用的字符串常量,打印出来
(gdb) x/s $rdi # rdi 是我们输入的字符串
(gdb) ni # 逐条汇编指令执行
(gdb) info registers # 随时查看各寄存器状态
(gdb) x/8gx $rsp # 看当前栈帧内容
(gdb) print $eax # 查看函数返回值 / 比较结果
(gdb) continue # 继续运行到下一个断点
快速速查卡
| 目的 | 命令 |
|---|---|
| 查寄存器 | info reg / p $rax |
| 查变量值 | p x / p &x(地址) |
| 查内存字节 | x/Nxb 地址 |
| 查内存字符串 | x/s $rdi |
| 查栈内容 | x/8gx $rsp |
| 看汇编 | disas / x/10i $rip |
| 看源码 | list |
| 混合汇编+源码 | disas /m |
| 单步汇编 | ni / si |
| 自动刷新显示 | display $rip |